#!/usr/bin/env bash

#
# Common error handling
#

exit_trap_cmds=()

on_exit() {
    exit_trap_cmds+=( "$1" )
}

run_exit_trap_cmds() {
    for cmd in "${exit_trap_cmds[@]}"; do
        eval "${cmd}"
    done
}

trap run_exit_trap_cmds EXIT

warn() {
    >&2 echo "Warning: $*"
}

bail() {
    if [[ $# -gt 0 ]]; then
        >&2 echo "Error: $*"
    fi
    exit 1
}

usage() {
    cat <<EOF
Usage: $0 -b GITREV_BEFORE -a GITREV_AFTER -o OUTPUT_DIR [-v VARIANT] [-r] [-h]
Compare kernel configurations before and after a series of commits.

    -a, --after         new Git revision to compare from
    -b, --before        baseline Git revision to compare against
    -v, --variant       variant to pick kernel from to compare configs for, may
                        be given multiple times (optional, defaults to this list:
                        'aws-k8s-1.23', 'metal-k8s-1.23', 'aws-dev', 'metal-dev')
    -o, --output-dir    path to the output directory; must not exist yet
    -r, --resume        resume work on a previous invocation; check which parts
                        already exist in OUTPUT_DIR and skip builds accordingly
    -h, --help          show this help text

Example invocation:

    This compares the config changes for kernels 5.10 (through metal-k8s-1.23)
    and 5.15 (through metal-k8s-1.26) before and after the most recent commit
    has been applied:

        $0 -b HEAD^ -a HEAD -v metal-k8s-1.23 -v metal-k8s-1.26 -o configs

Notes:

    This compares the config changes for all combinations of aarch64/x86_64,
    cloud/metal, and kernel versions. Combinations without a corresponding
    Bottlerocket variant are skipped. Since this involves numerous full kernel
    builds the comparison will take some time. Consider the working tree this
    is invoked on busy while the script is running.

EOF
}

usage_error() {
    >&2 usage
    bail "$1"
}


#
# Parse arguments
#

kernel_versions=()
variants=()

while [[ $# -gt 0 ]]; do
    case $1 in
        -a|--after)
            shift; gitrev_after_arg=$1 ;;
        -b|--before)
            shift; gitrev_before_arg=$1 ;;
        -v|--variant)
            shift; variants+=( "$1" ) ;;
        -o|--output-dir)
            shift; output_dir=$1 ;;
        -r|--resume)
            shift; resume=1 ;;
        -h|--help)
            usage; exit 0 ;;
        *)
            usage_error "Invalid option '$1'" ;;
    esac
    shift
done

if [[ ${#variants[@]} -eq 0 ]]; then
    variants=( aws-k8s-1.23 metal-k8s-1.23 aws-dev metal-dev )
fi

for var in "${variants[@]}"; do
    [[ -d variants/${var} ]] || bail "Unknown variant '${var}'"
done
readonly variants

[[ -n ${output_dir} ]] || usage_error 'require -o|--output-dir'
[[ -e ${output_dir} && ! -v resume ]] && bail "Output directory '${output_dir}' exists already, not touching it"
readonly output_dir

# Validate and resolve the given before and after Git revisions. Resolving
# them now prevents relative references from moving around after the first
# checkout.
[[ -n ${gitrev_before_arg} ]] || usage_error 'require -b|--before'
[[ -n ${gitrev_after_arg} ]] || usage_error 'require -a|--after'
gitrev_before=$(git rev-parse --verify --end-of-options "${gitrev_before_arg}^{commit}")
gitrev_after=$(git rev-parse --verify --end-of-options "${gitrev_after_arg}^{commit}")
[[ -n ${gitrev_before} ]] || bail "Invalid Git revision '${gitrev_before_arg}'"
[[ -n ${gitrev_after} ]] || bail "Invalid Git revision '${gitrev_after_arg}'"
readonly gitrev_before
readonly gitrev_after


#
# Prepare working tree
#

# We'll check out the before and after states to compare. For that the working
# tree and the index need to be clean.
if [[ -n $(git status --porcelain --untracked-files=no) ]]; then
    bail 'The working tree or index of the repository are not clean. ' \
         'Consider running "git stash" to temporarily stow away your changes.'
fi

# Restore current repository state whenever we exit (either a checked out
# branch or the current detached head state).
gitrev_original=$(git rev-parse --abbrev-ref HEAD)
if [[ -z ${gitrev_original} ]]; then
    gitrev_original=$(git rev-parse HEAD) || bail 'Cannot determine current repository HEAD.'
fi
readonly gitrev_original
on_exit "git checkout --quiet '${gitrev_original}'"


#
# Iterate over all viable build configurations in before and after states
#

mkdir -p "${output_dir}" || bail "Failed to create output directory '${output_dir}'"

for state in after before; do

    gitrev_var=gitrev_${state}
    git checkout --quiet "${!gitrev_var}" || bail "Cannot check out '${!gitrev_var}'."

    for variant in "${variants[@]}"; do

        arches=()
        IFS=" " read -r -a arches <<< "$(grep "supported-arches" "variants/${variant}/Cargo.toml" | cut -d ' ' -f 3 | tr -d '"[]')"
        if [[ ${#arches[@]} -eq 0 ]]; then
            arches=( aarch64 x86_64 )
        fi

        kver=$(grep "packages/kernel" "variants/${variant}/Cargo.toml" | cut -d ' ' -f 1 | cut -d '-' -f 2 | tr '_' '.')

        kernel_versions+=( "${kver}" )

        for arch in "${arches[@]}"; do
            config_path=${output_dir}/config-${arch}-${variant}-${state}

            if [[ -v resume && -e ${config_path} ]]; then
                echo "${config_path} already exists, skipping"
                continue
            fi

            debug_id="state=${state} arch=${arch} variant=${variant} kernel=${kver}"

            IFS=- read -ra variant_parts <<<"${variant}"
            variant_platform="${variant_parts[0]}"
            variant_runtime="${variant_parts[1]}"
            variant_family="${variant_platform}-${variant_runtime}"

            #
            # Run build
            #

            cargo make \
                    -e BUILDSYS_ARCH="${arch}" \
                    -e BUILDSYS_VARIANT="${variant}" \
                    -e BUILDSYS_VARIANT_PLATFORM="${variant_platform}" \
                    -e BUILDSYS_VARIANT_RUNTIME="${variant_runtime}" \
                    -e BUILDSYS_VARIANT_FAMILY="${variant_family}" \
                    -e PACKAGE="kernel-${kver/./_}" \
                    build-package \
                || bail "Build failed for ${debug_id}"

            #
            # Find kernel RPM
            #

            shopt -s nullglob
            kernel_rpms=(
                ./build/rpms/bottlerocket-*kernel-"${kver}"-"${kver}".*."${arch}".rpm
                ./build/rpms/bottlerocket-"${arch}"-*kernel-"${kver}"-"${kver}".*.rpm
            )
            shopt -u nullglob

            case ${#kernel_rpms[@]} in
                0) bail "No kernel RPM found for ${debug_id}" ;;
                1) kernel_rpm=${kernel_rpms[0]} ;;
                *)
                    # shellcheck disable=SC2012  # find(1) cannot sort by mtime
                    kernel_rpm=$(ls -1t "${kernel_rpms[@]}" | head -n 1)
                    warn "More than one kernel RPM found for ${debug_id}. Choosing '${kernel_rpm}' as the latest build."
                    ;;
            esac

            kver_full=$(rpm --query --queryformat '%{VERSION}-%{RELEASE}' "${kernel_rpm}")

            #
            # Extract kernel config
            #

            rpm2cpio "${kernel_rpm}" \
                | cpio --quiet --extract --to-stdout ./boot/config >"${config_path}"
            [[ -s "${config_path}" ]] || bail "Failed to extract config for ${debug_id}"


            echo "config-${arch}-${variant}-${state} -> ${kver_full}" >> "${output_dir}"/kver_mapping
        done  # arch

    done  # variant

done  # state


#
# Post-process the collected pairs of "before" and "after" configs (generate diffs, a report, a summary)
#

# Get the helpful diffconfig script from the kernel source tree. We package it
# in the kernel-archive RPM from where it can be extracted. Here we extract the
# latest version of the script, but any kernel version and arch will do.
latest_kver=$(printf '%s\n' "${kernel_versions[@]}" | sort -V | tail -n1)
latest_archive_rpms=( ./build/rpms/bottlerocket-*kernel-"${latest_kver}"-archive-*.rpm )
diffconfig=$(mktemp --suffix -bottlerocket-diffconfig)
on_exit "rm '${diffconfig}'"
rpm2cpio "${latest_archive_rpms[0]}" \
    | cpio --quiet --extract --to-stdout \
    | tar --xz --extract --to-stdout kernel-devel/scripts/diffconfig >"${diffconfig}"
[[ -s ${diffconfig} ]] || bail "Failed to extract diffconfig tool from '${latest_archive_rpms[0]}'."
chmod +x "${diffconfig}"

# Diff the before and after states for each collected pair
for config_before in "${output_dir}"/config-*-before; do
    config_after=${config_before/before/after}
    config_diff=${config_before/before/diff}
    "${diffconfig}" "${config_before}" "${config_after}" >"${config_diff}" \
        || bail "Failed to diff '${config_before}' and '${config_after}'"
done

# Generate diff summary
echo
for config_diff in "${output_dir}"/config-*-diff; do
    config_base=${config_diff##*/}
    awk "
        /^-/   { removed += 1 }
        /^+/   { added   += 1 }
        / -> / { changed += 1 }
        END    { printf \"${config_base}:\t%3d removed, %3d added, %3d changed\n\", removed, added, changed }
    " "${config_diff}"
done | sort -V | tee "${output_dir}"/diff-summary
echo

# Generate combined report of changes
head -v -n 999999 "${output_dir}"/*-diff >"${output_dir}"/diff-report
echo "A full report has been placed in '${output_dir}/diff-report'"

# Generate combined report in tabular form (csv)
echo "config change" > "${output_dir}"/diff-table
cat "${output_dir}"/*-diff | sort | uniq >> "${output_dir}"/diff-table

for config_diff in "${output_dir}"/config-*-diff; do
    variant_name=$(echo "${config_diff}" | sed -e "s%^${output_dir}/config-%%" -e "s%-diff$%%")
    kver_before=$(grep "${variant_name}-before" "${output_dir}/kver_mapping" | cut -d ' ' -f 3)
    kver_after=$(grep "${variant_name}-after" "${output_dir}/kver_mapping" | cut -d ' ' -f 3)
    col_name="${variant_name} (${kver_before} -> ${kver_after})"

    sed -i "s/$/,/" "${output_dir}"/diff-table
    sed -i "/^config change/ s/$/${col_name}/" "${output_dir}"/diff-table

    mapfile -t diff_lines < "${config_diff}"

    for line in "${diff_lines[@]}"; do
        sed -i "/^${line}/ s/$/x/" "${output_dir}"/diff-table
    done
done
echo "A tabular report in csv-format has been placed in '${output_dir}/diff-table'"
